# Copyright (C) 2024 Alexander Linkov <kvark128@yandex.ru>
# This file is covered by the GNU General Public License.
# See the file COPYING.txt for more details.
# Ukrainian Nazis and their accomplices are not allowed to use this plugin. Za pobedu!

import threading
import queue
import importlib
from collections import OrderedDict, defaultdict
from io import StringIO
from configobj import ConfigObj
from dataclasses import dataclass
from typing import Optional

import synthDriverHandler
import queueHandler
import languageHandler
import addonHandler
import config
from autoSettingsUtils.utils import StringParameterInfo
from autoSettingsUtils.driverSetting import BooleanDriverSetting, DriverSetting
from synthDriverHandler import VoiceInfo, synthDoneSpeaking, synthIndexReached
from synthDrivers.silence import SynthDriver as SilenceSynthDriver
from speech.commands import IndexCommand, CharacterModeCommand, PitchCommand, LangChangeCommand, BaseProsodyCommand
from logHandler import log

from . import languageDetection

addonHandler.initTranslation()

@dataclass
class VoiceSettings:
	synth: str = synthDriverHandler.defaultSynthPriorityList[0]
	profile: Optional[str] = None
	sendLang: bool = True

@dataclass
class ScriptSettings:
	latin: str = "en"
	cyrillic: str = "ru"
	arabic: str = "ar"
	CJK: str = "zh"

configSpecString = f"""
[languages]
	[[__many__]]
		synth = string(default={synthDriverHandler.defaultSynthPriorityList[0]})
		profile = string(default=None)
		sendLang = boolean(default=true)
"""

def _getSynthInstance(name):
	return importlib.import_module(f"synthDrivers.{name}", package="synthDrivers").SynthDriver()

class EventWatcher(threading.Thread):

	def __init__(self, event):
		super().__init__()
		self._queue = queue.Queue()
		self._event = event
		self.daemon = True
		self.start()

	def run(self):
		while True:
			timeout = self._queue.get()
			if timeout is None:
				break
			if not self._event.wait(timeout):
				self._event.set()

	def watch(self, timeout):
		self._queue.put(timeout)

class SynthDriver(synthDriverHandler.SynthDriver):
	name = "MultiLang"
	description = _("MultiLang")

	def __init__(self):
		self._doneSpeakingFlag = threading.Event()
		self._doneSpeakingFlag.set()
		self._doneSpeakingWatcher = EventWatcher(self._doneSpeakingFlag)
		self._synthCache = {}
		self._synth = None
		self.confspec = ConfigObj(StringIO(configSpecString), list_values=False, encoding="UTF-8")
		self.speechQueue = queue.Queue()

		self._voice = languageHandler.getLanguage().split("_")[0]
		if self._voice not in self.availableVoices:
			self._voice = "en"
		self.useUnicodeLanguageDetection = True
		self.ignoreNumbersInLanguageDetection = False
		self.ignorePunctuationInLanguageDetection = True

		self._settings = defaultdict(lambda: VoiceSettings())
		self._scriptSettings = ScriptSettings()
		self._languageDetector = languageDetection.LanguageDetector(self.availableVoices.keys(), self._scriptSettings)

		self._originalDefaultValueForBaseProsodyCommand = BaseProsodyCommand.defaultValue
		BaseProsodyCommand.defaultValue = property(lambda cmd: getattr(self._synth, cmd.settingName))

		synthDoneSpeaking.register(self._onSynthDoneSpeaking)
		synthIndexReached.register(self._onSynthIndexReached)

	def getConfigSpec(self):
		spec = super().getConfigSpec()
		spec.update(self.confspec)
		for lang in self.availableVoices:
			spec["languages"][lang] = self.confspec["languages"]["__many__"]
		return spec

	def saveSettings(self):
		super().saveSettings()
		conf = config.conf["speech"][self.name]["languages"]
		for lang, settings in self._settings.items():
			langConf = conf[lang]
			if langConf["synth"] != settings.synth:
				langConf["synth"] = settings.synth
			if langConf["profile"] != settings.profile:
				langConf["profile"] = settings.profile
			if langConf["sendLang"] != settings.sendLang:
				langConf["sendLang"] = settings.sendLang

	def loadSettings(self, onlyChanged=False):
		conf = config.conf["speech"][self.name]["languages"]
		for lang in self.availableVoices:
			if not conf.isSet(lang):
				continue
			settings = self._settings[lang]
			for param, value in conf[lang].items():
				if not conf[lang].isSet(param):
					continue
				if param == "profile" and value in self.availableProfiles:
					settings.profile = value
				elif param == "synth" and value in self.availableSynths:
					settings.synth = value
				elif param == "sendLang" and isinstance(value, bool):
					settings.sendLang = value
		super().loadSettings(onlyChanged=onlyChanged)

	def terminate(self):
		self.cancel()
		self._doneSpeakingWatcher.watch(None)
		self._doneSpeakingWatcher.join()
		synthIndexReached.unregister(self._onSynthIndexReached)
		synthDoneSpeaking.unregister(self._onSynthDoneSpeaking)
		BaseProsodyCommand.defaultValue = self._originalDefaultValueForBaseProsodyCommand
		self._synth = None
		for synth, defaultConf in self._synthCache.values():
			synth.cancel()
			synth.terminate()
		self._synthCache.clear()

	@classmethod
	def check(cls):
		return True

	supportedSettings = (
		DriverSetting("voice", _("Default &voice"), availableInSettingsRing=True),
		DriverSetting("synth", _("&Synthesizer"), availableInSettingsRing=True),
		BooleanDriverSetting("sendLang", _("Send text language to synthesizer"), availableInSettingsRing=True, defaultVal=True),
		DriverSetting("profile", _("&Profile"), availableInSettingsRing=True),
		BooleanDriverSetting("useUnicodeLanguageDetection", _("Use language detection based on Unicode characters"), availableInSettingsRing=True, defaultVal=True),
		BooleanDriverSetting("ignoreNumbersInLanguageDetection", _("Ignore numbers in language detection"), availableInSettingsRing=False, defaultVal=False),
		BooleanDriverSetting("ignorePunctuationInLanguageDetection", _("Ignore punctuation in language detection"), availableInSettingsRing=False, defaultVal=True),
		DriverSetting("latinVoice", _("&Latin voice"), availableInSettingsRing=False, defaultVal="en"),
		DriverSetting("cyrillicVoice", _("&Cyrillic voice"), availableInSettingsRing=False, defaultVal="ru"),
		DriverSetting("arabicVoice", _("&Arabic voice"), availableInSettingsRing=False, defaultVal="ar"),
		DriverSetting("CJKVoice", _("&CJK characters voice"), availableInSettingsRing=False, defaultVal="zh"),
	)

	supportedCommands = {IndexCommand, CharacterModeCommand, PitchCommand, LangChangeCommand}
	supportedNotifications = {synthIndexReached, synthDoneSpeaking}

	def _onSynthDoneSpeaking(self, synth):
		if synth is not self:
			if synth.name == "sapi5" and synth.tts.Status.RunningState == 0:
				# Bug in the SAPI5 driver
				return
			if not self._doneSpeakingFlag.isSet():
				self._doneSpeakingFlag.set()
				synthDoneSpeaking.notify(synth=self)
				return
			queueHandler.queueFunction(queueHandler.eventQueue, self.pumpSpeech, synth, _immediate=True)

	def _onSynthIndexReached(self, synth, index):
		if synth is not self:
			synthIndexReached.notify(synth=self, index=index)

	def pumpSpeech(self, synth=None):
		if synth is not self._synth:
			if synth is not None:
				log.warning(f"Unexpected done speaking event: event synth: {synth}; current synth: {self._synth}")
			return

		try:
			lang, speechSequence = self.speechQueue.get_nowait()
		except queue.Empty:
			synthDoneSpeaking.notify(synth=self)
			self._synth = None
			return

		self._synth = self._getSynth(lang)
		# Need to make sure that the synthesizer is not paused
		self._synth.cancel()

		if self._settings[lang].sendLang:
			speechSequence.insert(0, LangChangeCommand(lang))
		self._synth.speak(speechSequence)

	def _getSynth(self, lang):
		settings = self._settings[lang]

		synth, defaultConf = self._synthCache.get(settings.synth, (None, {}))
		if synth is None:
			try:
				synth = _getSynthInstance(settings.synth)
			except Exception as e:
				for fallbackSynthName in synthDriverHandler.defaultSynthPriorityList:
					if fallbackSynthName not in (settings.synth, SilenceSynthDriver.name):
						synth = _getSynthInstance(fallbackSynthName)
						settings.synth = fallbackSynthName
						settings.profile = None
						self.saveSettings()
						break
				else:
					raise e

			# Make sure the instance has attributes for every setting
			for setting in synth.supportedSettings:
				if not hasattr(synth, setting.id):
					setattr(synth, setting.id, setting.defaultVal)
				# Make sure the driver settings match the actual settings of the synthesizer
				val = getattr(synth, setting.id)
				setattr(synth, setting.id, val)
				defaultConf[setting.id] = val

			# Care must be taken that the new synth cannot save its settings to the NVDA configuration
			synth._unregisterConfigSaveAction()
			synth.saveSettings = lambda: None
			self._synthCache[settings.synth] = (synth, defaultConf)

		conf = None
		profile = config.conf._getProfile(settings.profile)
		if "speech" in profile and settings.synth in profile["speech"]:
			conf = profile["speech"][settings.synth]
			conf.configspec = synth.getConfigSpec()
			profile.validate(config.conf.validator, section=conf)

		# If the synth supports a voice, then we must first initialize it, since other parameters can be set specifically for this voice
		if synth.isSupported("voice"):
			voice = conf["voice"] if conf is not None and "voice" in conf else defaultConf["voice"]
			if voice is not None and voice != synth.voice:
				synth.voice = voice

		for setting in synth.supportedSettings:
			if setting.id == "voice":
				continue
			val = conf[setting.id] if conf is not None and setting.id in conf else defaultConf[setting.id]
			if not setting.useConfig or val is None:
				continue
			if getattr(synth, setting.id) == val:
				continue
			setattr(synth, setting.id, val)
		return synth

	def speak(self, speechSequence):
		if self.useUnicodeLanguageDetection:
			speechSequence = self._languageDetector.add_detected_language_commands(speechSequence, self._voice, self.ignoreNumbersInLanguageDetection, self.ignorePunctuationInLanguageDetection)

		lang = self._voice
		newSpeechSequence = []
		hasText = False
		for item in speechSequence:
			if isinstance(item, str):
				if not item.isspace():
					newSpeechSequence.append(item)
					hasText = True
			elif isinstance(item, LangChangeCommand):
				if item.lang == lang:
					continue
				if hasText:
					self.speechQueue.put((lang, newSpeechSequence))
					newSpeechSequence = list(filter(lambda e: not isinstance(e, (str, IndexCommand)), newSpeechSequence))
					hasText = False
				# For an unknown language, use the default voice/language
				lang = item.lang if item.lang in self.availableVoices else self._voice
			else:
				newSpeechSequence.append(item)

		self.speechQueue.put((lang, newSpeechSequence))
		self.pumpSpeech()

	def pause(self, switch):
		if self._synth is not None:
			self._synth.pause(switch)

	def cancel(self):
		try:
			while True:
				self.speechQueue.get_nowait()
		except queue.Empty:
			pass

		if self._synth is not None:
			self._doneSpeakingFlag.clear()
			self._synth.cancel()
			self._synth = None
			# self._doneSpeakingFlag must be set even if the canceled synth does not. A timeout of 25 ms is enough
			self._doneSpeakingWatcher.watch(0.025)

	def _getAvailableVoices(self):
		langs = languageDetection.LATIN + languageDetection.CYRILLIC + languageDetection.ARABIC + languageDetection.CJK + list(languageDetection.SINGLETONS.values())
		langs.sort()
		voices = OrderedDict()
		for lang in langs:
			description = languageHandler.getLanguageDescription(lang)
			voices[lang] = VoiceInfo(lang, f"{description}, {lang}", lang)
		return voices

	def _get_voice(self):
		return self._voice

	def _set_voice(self, voice):
		if voice in self.availableVoices:
			self._voice = voice

	def _getAvailableSynths(self):
		synths = OrderedDict()
		for id, displayName in synthDriverHandler.getSynthList():
			if id in (self.name, SilenceSynthDriver.name):
				continue
			synths[id] = StringParameterInfo(id, displayName)
		return synths

	def _get_availableSynths(self):
		if not hasattr(self, "_availableSynths"):
			self._availableSynths = self._getAvailableSynths()
		return self._availableSynths

	def _get_synth(self):
		return self._settings[self._voice].synth

	def _set_synth(self, synth):
		if synth in self.availableSynths:
			self._settings[self._voice].synth = synth

	def _getAvailableProfiles(self):
		profiles = OrderedDict([(None, StringParameterInfo(None, _("Default")))])
		for id in config.conf.listProfiles():
			profiles[id] = StringParameterInfo(id, id)
		return profiles

	def _get_availableProfiles(self):
		if not hasattr(self, "_availableProfiles"):
			self._availableProfiles = self._getAvailableProfiles()
		return self._availableProfiles

	def _get_profile(self):
		return self._settings[self._voice].profile

	def _set_profile(self, profile):
		if profile in self.availableProfiles:
			self._settings[self._voice].profile = profile

	def _get_sendLang(self):
		return self._settings[self._voice].sendLang

	def _set_sendLang(self, value):
		if isinstance(value, bool):
			self._settings[self._voice].sendLang = value

	def _get_availableLatinvoices(self):
		voices = OrderedDict()
		for lang, info in self.availableVoices.items():
			if lang in languageDetection.LATIN:
				voices[lang] = info
		return voices

	def _get_latinVoice(self):
		return self._scriptSettings.latin

	def _set_latinVoice(self, voice):
		if voice in self.availableLatinvoices:
			self._scriptSettings.latin = voice

	def _get_availableCyrillicvoices(self):
		voices = OrderedDict()
		for lang, info in self.availableVoices.items():
			if lang in languageDetection.CYRILLIC:
				voices[lang] = info
		return voices

	def _get_cyrillicVoice(self):
		return self._scriptSettings.cyrillic

	def _set_cyrillicVoice(self, voice):
		if voice in self.availableCyrillicvoices:
			self._scriptSettings.cyrillic = voice

	def _get_availableArabicvoices(self):
		voices = OrderedDict()
		for lang, info in self.availableVoices.items():
			if lang in languageDetection.ARABIC:
				voices[lang] = info
		return voices

	def _get_arabicVoice(self):
		return self._scriptSettings.arabic

	def _set_arabicVoice(self, voice):
		if voice in self.availableArabicvoices:
			self._scriptSettings.arabic = voice

	def _get_availableCjkvoices(self):
		voices = OrderedDict()
		for lang, info in self.availableVoices.items():
			if lang in languageDetection.CJK:
				voices[lang] = info
		return voices

	def _get_CJKVoice(self):
		return self._scriptSettings.CJK

	def _set_CJKVoice(self, voice):
		if voice in self.availableCjkvoices:
			self._scriptSettings.CJK = voice
